io_uring Async I/O

导言

读到 Tutti 的 GPU io_uring 一节时,我几乎完全没看懂。SQ、CQ、IOCB、CUDA Event 和 Green Context 挤在一起,很难看出它们各自解决什么问题。

本文先用取餐叫号解释核心直觉,再用 Linux io_uring 的 SQ、CQ、SQE、CQE 和 SQPOLL 建立准确模型,最后把这些对象放回 Tutti:为什么队列能把发起请求等待结果拆成两个时间点,以及为什么还需要 CUDA Event、Green Context 和 slack-aware Scheduler 才能真正减少 GPU 停顿。

一句话结论:io_uring 不负责消灭等待,而是让程序只在真正要使用数据时才等待,并把等待前的空档留给其他计算。

先回答核心问题

io_uring 为什么不会阻塞计算提交?”这个问题里其实混合了三个动作:

  1. 提交 I/O:告诉系统“请读取这块数据”。
  2. 提交计算:告诉 CPU 或 GPU“接下来执行这些计算任务”。
  3. 使用数据:计算真正读取刚才请求的数据。

io_uring 主要解决第一步和第二步之间的耦合:程序把 I/O 请求写进队列后,可以继续提交不依赖该数据的计算,不必站在原地等 I/O 完成。

但是到了第三步,数据必须已经就绪。若计算追上了 I/O,仍然要等待:

$$
T_{stall}=\max(0, T_{I/O}-T_{overlap})
$$

其中 $T_{overlap}$ 是 I/O 期间可以并行执行的有效计算时间。异步 I/O 的收益不是把 $T_{I/O}$ 变成 0,而是尽量用计算覆盖它。

三种不同的“不阻塞”

  • 调用者不阻塞:提交函数很快返回,调用线程可以继续工作。
  • I/O 与计算能重叠:存储设备搬数据时,处理器确实有独立计算可做。
  • I/O 不干扰计算:I/O 控制任务不会抢走计算需要的 CPU、GPU 或 PCIe 资源。

队列主要帮助实现第一项;是否实现后两项,还取决于硬件并行能力、依赖关系和资源调度。

第一层:取餐叫号

同步方式:守在窗口前

假设餐厅只有一个取餐窗口。你点完餐后一直站在窗口前,直到厨师把饭递给你,才去处理下一件事。

这类似同步读取:

1
提交读取 A → 等待 A → 使用 A → 提交读取 B → 等待 B → 使用 B

厨师做饭时,你没有参与做饭,但你的行动被卡住了。这里的“阻塞”并不是说 CPU 一直在忙等,而是说这条执行线暂时不能推进后续工作

io_uring:点单架与取餐屏

现在餐厅增加两样东西:

  • 点单架:顾客把订单依次放上去,后厨按顺序取走。
  • 取餐屏:饭做好后显示订单号,顾客看到自己的号码再来取。

你可以一次放上十张订单,然后去整理桌面、接电话或准备下一批材料。后厨独立处理订单;取餐屏出现号码时,你才处理结果。

这两个结构分别对应:

  • Submission Queue,SQ:提交队列,存“想做什么”。
  • Completion Queue,CQ:完成队列,存“什么已经做完,以及结果如何”。

流程变成:

1
2
3
4
5
6
7
提交读取 A ─┐
提交读取 B ├─→ 继续做不依赖 A、B 的计算
提交读取 C ─┘

稍后检查完成队列 → A 完成 → 使用 A
→ C 完成 → 使用 C
→ B 完成 → 使用 B

完成顺序不必和提交顺序一致,因此每张订单都需要唯一编号,程序才能知道某个完成通知属于哪个请求。

为什么叫 Ring

普通队列若不断加入新元素,可能频繁申请和释放内存。Ring Buffer 则像一个首尾相接的固定取餐架:

1
2
3
槽位: [0] [1] [2] [3] [4] [5]
↑ head ↑ tail
下一个取走 下一个写入
  • 生产者tail 指向的空槽写入请求,然后推进 tail
  • 消费者head 指向的槽取走请求,然后推进 head
  • 指针到达末尾后绕回开头,已经消费的槽位可以重复使用。
  • 如果生产太快,tail 追上尚未消费的 head,队列就满了,必须施加背压。

固定槽位减少了逐请求内存分配;生产者和消费者主要操作各自负责的指针,也更适合低开销并发。

类比到这里为止

取餐类比没有展示内核、设备驱动、DMA、Memory Ordering 和错误处理。真实的 io_uring 也不是把任意工作扔进队列就必然异步完成;它只是提供了一套高效的提交与完成接口。存储设备仍有带宽上限,数据没到之前仍不能被使用。

第二层:Linux 的准确机制

四个核心对象

Linux io_uring 建立在两块由用户态与内核共享的环形队列之上:

对象 全称 保存什么 主要写入者
SQ Submission Queue 待处理请求的位置索引 用户态程序
SQE Submission Queue Entry 操作类型、文件、地址、长度、偏移等 用户态程序
CQ Completion Queue 已完成结果的位置 内核
CQE Completion Queue Entry 返回值、错误码、请求标识等 内核

应用通过 io_uring_setup 建立 Ring,再用 mmap 共享队列内存。之后,大多数请求不需要为“参数从用户态传到内核”反复复制一套控制结构;双方围绕共享 Ring 的 Head/Tail 协作。

这里的 Lock-free 不等于没有同步。生产者必须先完整写好 SQE,再用正确的原子操作和 Memory Ordering 发布新的 tail;消费者看到 tail 后,才能安全读取该条目。

一次读取的生命周期

一次典型的 Linux io_uring 读取可拆成六步:

  1. 取得 SQE:应用从 SQ 中取得一个空槽。
  2. 描述请求:填写 READ、File Descriptor、Buffer、Offset、Length 和 user_data
  3. 发布请求:推进 SQ Tail,使内核能够看见一个或多个新请求。
  4. 执行 I/O:内核或相应 Worker/Driver 处理请求,设备把数据送入目标 Buffer。
  5. 写入 CQE:操作完成后,内核把结果和原来的 user_data 写进 CQ。
  6. 消费结果:应用读取 CQE,确认成功、失败或实际传输长度,然后推进 CQ Head。
1
2
3
4
5
6
7
8
flowchart LR
App[应用准备 SQE] --> SQ[Submission Queue]
SQ --> Kernel[内核处理请求]
Kernel --> Device[NVMe / 网络设备]
Device --> Buffer[数据进入目标 Buffer]
Device --> Kernel
Kernel --> CQ[Completion Queue]
CQ --> App

应用不必在第 3 步之后立刻执行第 6 步。它可以先做其他工作,只在结果成为后续计算的必要输入时检查或等待 CQE。

系统调用为什么会减少

普通模式下,应用通常仍要通过 io_uring_enter 通知内核处理新 SQE。关键区别是:一次调用可以提交一批 SQE,而不是每个小 I/O 都单独进行一次系统调用。

若启用 IORING_SETUP_SQPOLL,内核会使用专门的 Kernel Thread 轮询 SQ。只要该线程没有因空闲而睡眠,应用发布 SQ Tail 后甚至不必再用 io_uring_enter 踢一下内核。

这带来两个层次的优化:

  1. Batching:$N$ 个请求可以用远少于 $N$ 次的系统调用提交。
  2. SQPOLL:在合适的高频负载中,常规提交路径可以进一步避免系统调用。

但 SQPOLL 不是免费的。轮询线程会消耗 CPU;进入睡眠后仍需要系统调用唤醒;等待完成、注册资源和异常路径也可能需要系统调用。低频 I/O 开启轮询,可能只是用更多 CPU 换来没有实际价值的低延迟。

为什么程序可以继续计算

对比下面两段伪代码:

1
2
3
4
5
6
7
8
9
# 同步
data = read(file) # 数据回来前,这条执行线停在这里
result = compute(data)

# 异步流水线
submit_read(file_A, buf_A)
compute_independent_work() # 与 A 无关,可以立即执行
wait_completion(A) # 只有现在确实要用 A,才需要等
result = compute(buf_A)

真正重要的是 等待位置后移。如果 submit_read 后马上 wait_completion,程序虽然使用了异步 API,行为仍接近同步;如果中间存在足够多的独立计算,I/O 时延才可能被隐藏。

除了异步还有什么好处

io_uring 的价值不止“不阻塞调用者”:

  • 批量提交与收割:一次发布多个 SQE,也可以一次处理多个 CQE,摊薄固定开销。
  • 保持较高 Queue Depth:设备同时看到更多在途请求,NVMe 可以利用内部并行性,而不是每完成一个才收到下一个。
  • 减少动态分配:预先建立的 Ring 循环复用固定槽位。
  • 显式关联结果:通过 user_data 将乱序完成的 CQE 匹配回原请求。
  • 注册文件与 Buffer:重复使用固定资源时,可提前完成部分查找、Pinning 和 Mapping 工作,减少逐 I/O 开销。
  • 统一多类异步操作:文件、网络、Timeout 和 Poll 等操作可以通过相近的提交—完成模型组合。
  • 自然表达背压:SQ 满表示生产速度超过消费能力,程序可以限制并发,而不是无限创建线程或请求对象。

其中,Registered Buffer 是独立的性能优化;io_uring 本身不等于 Zero-copy。是否经过 Page Cache、是否使用 O_DIRECT、数据最终由谁 DMA 到哪里,仍取决于具体 I/O 路径。

从 io_uring 到 Tutti

Tutti 为什么借用这套设计

Tutti 面对的不是普通应用读取一个大文件,而是从 NVMe 恢复大量细碎 KV Cache Object。若 CPU 为每个 Object 单独准备、提交并处理完成,GPU 可能算完当前 Layer 后仍在等 CPU 发起下一批 I/O。

Tutti 因此借用了“提交队列 + 完成队列”的核心思想,但它的 gio_uring 不是 Linux io_uring 原封不动地运行在 GPU 上

维度 Linux io_uring Tutti gio_uring
主要场景 用户进程向 Linux 提交异步操作 GPU 发起大量 KV Object 的 NVMe I/O
Ring 位置 用户态与内核共享的内存 GPU HBM,并以 Non-cached mmap 映射给 CPU
SQ 单元 通常一个 SQE 描述一个操作 一个 IOCB 批量包含最多 2048 个 IOCTX
请求准备 用户态应用填写 SQE CPU Runtime 根据 Prefix 与 Object Mapping 提前填写 IOCB
运行时发起 内核消费 SQE 并驱动 I/O GPU I/O Kernel 生成并提交 NVMe Command
完成通知 内核写 CQE GPU I/O Kernel 确认完成后把 IOCB Index 原子写入 CQ

所以更准确的概括是:CPU-prepared,GPU-executed。 CPU 没有从系统中消失;它仍负责 Prefix 查询、对象映射和提前准备 IOCB。被移出关键路径的是“每层运行到这里以后,再由 CPU 逐块发起并收割 I/O”的工作。

IOCB 与 IOCTX 是什么

Tutti 的一个逻辑批次叫 I/O Control Block,IOCB。每个 IOCB 内有一组 I/O Context,IOCTX;一个 IOCTX 记录:

  • SGL Address:目标 GPU Memory 的物理区间描述。
  • GPU File Offset:KV Object 在 SSD 文件中的位置。
  • Length:本次传输长度。

论文实现中,一个 IOCB 可以容纳 2048 个 IOCTX。这样,一个 GPU I/O Kernel 能让大量 GPU Thread 并行生成、提交细粒度 NVMe Command,既减少逐请求控制开销,也用较高的并发度喂满 SSD。

一批 KV 恢复如何流动

Tutti 论文给出的流程可以整理为:

  1. **init_queue(depth)**:在 HBM 中建立 SQ 和 CQ,并为 IOCB 分配唯一 Index。
  2. **get_iocb(nums, event)**:CPU 根据映射提前取得并填写 IOCB,同时记录必要的 CUDA Event。
  3. 提前 Enqueue:CPU 把后续 I/O Kernel 与 Model Compute Kernel 都放进 GPU 执行计划,不等执行到某层才临时逐块发请求。
  4. **issue_io(IOCB_ids, SMs)**:依赖满足后,GPU I/O Kernel 在指定 SM 上运行,生成 NVMe Command。
  5. P2P DMA:NVMe 与目标 HBM Buffer 直接传输 KV Payload,不经普通 Host DRAM Bounce Buffer。
  6. 完成入队:GPU I/O Kernel 观察设备完成后,把 IOCB Index 原子写入 CQ。
  7. **wait_cqe()**:计算真正需要某层 KV 前,检查对应 IOCB 是否完成;若还没完成,才在这个依赖点等待。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
sequenceDiagram
participant CPU as CPU Runtime
participant SQ as SQ / IOCB in HBM
participant GIO as GPU I/O Domain
participant SSD as NVMe SSD
participant HBM as KV Buffer in HBM
participant CQ as CQ in HBM
participant Compute as GPU Compute Domain

CPU->>SQ: 提前填写多层 IOCB
CPU-->>GIO: Enqueue I/O Kernel
CPU-->>Compute: Enqueue Compute Kernel
GIO->>SQ: 依赖满足后取得 IOCB
GIO->>SSD: 批量提交 NVMe Command
SSD-->>HBM: P2P DMA 写入 KV
SSD-->>GIO: 返回完成状态
GIO->>CQ: 写入 IOCB Index
Compute->>CQ: 使用该层 KV 前检查完成
CQ-->>Compute: 完成后继续 Attention

这里的 Zero-copy 有两个不同含义:Ring 让 CPU 与 GPU 共享 IOCB,减少控制结构的运行时分配和复制;P2P DMA 则让 KV Payload 在 NVMe 与 HBM 之间直接移动。队列控制面不复制,并不自动推出数据面也不复制。

逐层流水线如何隐藏 I/O

假设 Layer 0 的 KV 已经恢复,GPU 开始计算 Layer 0。此时可以并行恢复 Layer 1;计算进入 Layer 1 后,又并行恢复 Layer 2:

时间窗口 Compute Domain I/O Control Domain 与 SSD
Warm-up 等待 Layer 0 的必要 KV 恢复 Layer 0
Window 0 计算 Layer 0 恢复 Layer 1
Window 1 计算 Layer 1 恢复 Layer 2
Window 2 计算 Layer 2 恢复 Layer 3

若 Layer 1 的 I/O 在 Window 0 结束前完成,计算可以无缝进入下一层;若没有完成,Layer 1 Attention 仍会等待。这就是“隐藏 I/O”而不是“消灭 I/O”。

对于“为什么不阻塞计算提交”,现在可以给出更精确的回答:

  • CPU 可以提前 Enqueue 后续 I/O Kernel 与 Compute Kernel,不必同步等待每批 SSD 请求。
  • CUDA Event 只在真实依赖处约束执行顺序,独立 Kernel 可以先运行。
  • CQ 让完成状态异步返回,调用方不必为每个 IOCTX 接收一次同步响应。
  • 只有计算消费某层 KV 时,wait_cqe() 才可能形成等待。

为什么只有 Ring 还不够

如果把上面的全部收益都归功于 SQ/CQ,就会漏掉 Tutti 最关键的三个配套设计。

机制 解决的问题 没有它会怎样
CUDA Event 数据依赖与 Buffer 生命周期 I/O 可能覆盖仍在使用的 Buffer,计算也可能读取未完成数据
Green Context GPU SM 资源隔离 长运行 I/O Kernel 可能占住 SM,延迟关键 Compute Kernel
Slack-aware Scheduler 决定何时发、发多少、读写如何错开 I/O 与计算争用资源,或 Read/Write 同时压 SSD 导致带宽下降

普通 CUDA Stream 只能表达一定的执行顺序和并发机会,不能保证两个 Kernel 公平共享 SM。Tutti 用 NVIDIA Green Context 划分 Compute Domain 与 I/O Control Domain,让 I/O Kernel 只能使用预留资源;再根据离线 Profile 得到的 Slack Window 决定 IOCB Batch 大小。

因此,Tutti 减少 GPU Bubble 依赖的是一套组合:

1
2
3
4
5
6
SQ / CQ              → 解耦请求生产与完成消费
IOCB Batching → 一次处理大量细粒度 KV Object
P2P DMA → 数据绕过 Host DRAM 中转
CUDA Event → 保证数据依赖正确
Green Context → 隔离 I/O 与计算使用的 SM
Slack-aware Schedule → 把 I/O 放进合适的计算空档

最容易理解错的地方

五个边界

  1. 异步不等于更快完成。 单次 SSD 读取的物理时延未必因为 Ring 而降低。收益常来自更少的控制开销、更高的批量与并发,以及用其他工作覆盖等待时间。
  2. 提交完成不等于 I/O 完成。 “成功把请求放进 SQ”只说明系统接收了任务。只有对应 CQE 或 CQ 中的 IOCB Index 出现,才能说明设备操作已经结束;还要继续检查返回值和错误状态。
  3. 异步不等于永远不等待。 数据依赖无法取消。消费者追上生产者时必须等待;Queue 满、Buffer 不可复用或设备过载时也会产生背压。
  4. io_uring 不等于 Zero-copy。 共享 Ring 主要减少控制信息搬运。Payload 是否绕过 Page Cache、Host DRAM 或其他 Bounce Buffer,由 O_DIRECT、Registered Buffer、GDS/P2P DMA 和设备路径共同决定。
  5. gio_uring 不是 Linux 接口的简单移植。 Tutti 借用了 SQ/CQ 与异步完成模型,但重新定义了 Queue Entry、执行者、内存位置和批处理粒度。理解 Linux io_uring 有助于建立直觉,不能代替对 Tutti GPU Storage Stack 的分析。

两层说法对照

小白说法 专业术语 真正含义
把订单放上点单架 向 SQ 发布 SQE/IOCB 描述任务并更新 Tail,让消费者可见
后厨取走订单 Kernel 或 GPU I/O Kernel 消费提交项 异步执行或向设备进一步发出命令
厨房同时做多份饭 保持多个 In-flight I/O 与较高 Queue Depth 利用 NVMe 内部并行性,提高吞吐
取餐屏显示号码 CQ 写入 CQE/IOCB Index 返回完成状态并关联原请求
先去做别的事 Overlap I/O with Computation 把等待窗口用于独立计算
取餐前再看号码 在真实依赖点 Wait/Poll Completion 只在消费数据前确认完成
给厨房留一个专用工位 Green Context / SM Partitioning 避免 GPU I/O Kernel 与 Compute Kernel 无序争抢 SM
避开最忙时段下单 Slack-aware Scheduling 在资源空档中选择 I/O 时机和 Batch Size

自测题

先尝试用自己的话回答,不要只复述名词:

  1. 如果程序 submit_read() 后立刻调用 wait_completion(),它使用了异步 API,为什么运行效果仍可能接近同步 I/O?
  2. SQ/CQ、P2P DMA 和 Green Context 分别解决控制路径、数据路径与资源竞争中的什么问题?为什么其中任何一个都不能单独代表完整方案?
  3. 在逐层推理中,若每层计算需要 2 ms,而下一层 KV 恢复需要 3 ms,理想流水线下每层至少还会留下多少 I/O Stall?有哪些方法可以改变这个结果?

总结

io_uring 最值得掌握的不是两个 Ring 的名字,而是它重新安排了等待发生的位置:生产者先描述并发布一批任务,消费者异步执行,完成结果通过另一条队列返回,调用方只在真正消费数据前建立同步点。

Linux io_uring 借此减少系统调用、支持批量提交、维持设备并发并复用已注册资源。Tutti 的 gio_uring 则把相同的生产—执行—完成结构放进 GPU Storage Path,用 IOCB 批量承载成千上万个 KV I/O,并把逐块 NVMe Command 的运行时发起移出 CPU 关键路径。

但 Tutti 的“不阻塞”不是 SQ/CQ 单独创造的。它还依赖 CUDA Event 保证正确性、P2P DMA 绕过数据中转、Green Context 隔离 SM,以及 slack-aware Scheduler 找到真正可以隐藏 I/O 的计算窗口。队列让重叠成为可能,调度与资源隔离才让重叠变得可用。

参考资料

  1. Jens Axboe, io_uring(7) — Linux Manual Page。用于核对 SQ/CQ、Batch Submission 与 SQPOLL 的基本机制。
  2. Jens Axboe, liburingio_uring 用户态辅助库的上游实现与接口资料。
  3. Shi Qiu et al., Tutti: Making SSD-Backed KV Cache Practical for Long-Context LLM Serving,§3.2。gio_uring、IOCB/IOCTX、Green Context 与异步流程的主要来源。
  4. NVIDIA, CUDA Programming Guide: Green Contexts。用于核对 SM Resource Partition 的能力与限制。
  5. Michael Kerrisk / liburing Project, io_uring Registered Buffers。用于核对提前 Pin 和 Mapping Buffer 的收益与边界。
Author

Shaojie Tan

Posted on

2026-08-27

Updated on

2026-08-28

Licensed under